Skip to content

在 ASP.NET Core Web API 中實現可選更新功能

TLDR

  • 在 RESTful PATCH 操作中,透過自定義 OptionalValue<T> 結構,可精確區分「不更新該欄位」與「將欄位更新為 null」。
  • 使用 JsonConverter 處理 [FromBody] 請求,簡化 JSON 序列化格式。
  • 使用 ModelBinder 處理 [FromForm] 請求,解決表單資料繫結問題。
  • 透過實作 IModelValidator,確保 Data Annotation 驗證僅在欄位有值時觸發。
  • 透過 ISchemaFilterIOperationFilter 調整 Swagger 文件,使其正確呈現 API 規格。

在 ASP.NET Core Web API 中實現 PATCH 功能時,常面臨無法區分「客戶端未傳遞欄位(不更新)」與「客戶端傳遞 null(更新為 null)」的難題。特別是對於 struct 型別(如 DateTimeint),無法直接透過 null 來判斷。

解決此問題的核心思路是引入一個「註記欄位」來判斷是否需要更新。以下方案透過封裝 OptionalValue<T> 結構,並結合 ASP.NET Core 的擴充機制來達成。

可選屬性型別

什麼情況下會遇到這個問題:當 API 需要支援部分更新(Partial Update),且後端無法區分欄位是「未提供」還是「被設為 null」時。

我們建立一個 OptionalValue<T> 結構,利用 HasValue 屬性標記該欄位是否被傳遞。

csharp
public readonly record struct OptionalValue<T> {
    private readonly T value;

    public OptionalValue(T value) {
        HasValue = true;
        this.value = value;
    }

    public static OptionalValue<T> Empty() => new();

    [ValidateNever]
    public bool HasValue { get; }

    [ValidateNever]
    public T Value {
        get {
            if (!HasValue) {
                throw new InvalidOperationException("OptionalValue object must have a value.");
            }
            return value;
        }
    }

    public static implicit operator OptionalValue<T>(T value) {
        return new OptionalValue<T>(value);
    }

    public static explicit operator T(OptionalValue<T> value) {
        return value.Value;
    }
}

FromBody 的 JsonConverter

什麼情況下會遇到這個問題:當 API 使用 [FromBody] 接收 JSON 請求,且希望 JSON 結構保持簡潔,而非包含 hasValue 等額外屬性時。

透過自定義 JsonConverterJsonConverterFactory,可以將 JSON 序列化結果簡化為直接對應屬性值。

csharp
public class OptionalValueConverter<T> : JsonConverter<OptionalValue<T>> {
    public override OptionalValue<T> Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) {
        if (reader.TokenType == JsonTokenType.None) {
            return OptionalValue<T>.Empty();
        } else {
            T? value = JsonSerializer.Deserialize<T>(ref reader, options);
            return new OptionalValue<T>(value!);
        }
    }

    public override void Write(Utf8JsonWriter writer, OptionalValue<T> value, JsonSerializerOptions options) {
        if (value.HasValue) {
            JsonSerializer.Serialize(writer, value.Value, options);
        }
    }
}

FromForm 的 ModelBinder

什麼情況下會遇到這個問題:當 API 使用 [FromForm] 接收表單資料,且需要處理複雜的 Model 繫結時。

實作 IModelBinderIModelBinderProvider,讓 ASP.NET Core 能自動將表單欄位映射至 OptionalValue<T>

csharp
public class OptionalValueModelBinder<T> : IModelBinder {
    public Task BindModelAsync(ModelBindingContext bindingContext) {
        ValueProviderResult valueProviderResult = bindingContext.ValueProvider.GetValue(bindingContext.ModelName);

        if (valueProviderResult == ValueProviderResult.None) {
            bindingContext.Result = ModelBindingResult.Success(OptionalValue<T>.Empty());
            return Task.CompletedTask;
        }

        // 簡化處理邏輯,將字串轉換為目標型別
        string? valueStr = valueProviderResult.FirstValue;
        Type targetType = Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T);
        object? convertedValue = Convert.ChangeType(valueStr, targetType);
        
        bindingContext.Result = ModelBindingResult.Success(new OptionalValue<T>((T)convertedValue!));
        return Task.CompletedTask;
    }
}

處理資料驗證

什麼情況下會遇到這個問題:當 DTO 屬性上標註了 [Required][Range] 等驗證屬性,但希望在該欄位未被傳遞時跳過驗證。

透過實作 IModelValidator,我們可以確保驗證邏輯僅在 HasValuetrue 時執行。

csharp
public class OptionalValueValidator<T> : IModelValidator {
    private readonly ValidatorItem validatorItem;

    public OptionalValueValidator(ValidatorItem validatorItem) => this.validatorItem = validatorItem;

    public IEnumerable<ModelValidationResult> Validate(ModelValidationContext context) {
        if (context.Model is OptionalValue<T> optionalValue && optionalValue.HasValue) {
            // 執行實際的驗證邏輯
            // ...
        }
        return Enumerable.Empty<ModelValidationResult>();
    }
}

處理 Swagger Schema

什麼情況下會遇到這個問題:當上述客製化邏輯導致 Swagger 文件產出的 Schema 結構不符合預期,造成 API 文件難以閱讀時。

使用 ISchemaFilterIOperationFilter 修改 swagger.json 的產出,隱藏 OptionalValue 內部的 HasValue 屬性,讓前端開發者看到乾淨的欄位定義。


異動歷程

  • 2024-10-21 初版文件建立。
  • 2026-05-17 補上 GitHub 範例專案連結。